spec: define the confirmation contract behind action.ai.requiresConfirmation - #16531
Conversation
…irmation` Declares the request-side member, the refusal code and the refusal detail shape, and rewrites the two passages that described a queue the open framework path does not have. - `AIActionConfirmation` / `AI_ACTION_CONFIRMATION_MEMBER` / `ActionConfirmationRequiredDetails` in `contracts/ai-service.ts`. - `ACTION_CONFIRMATION_REQUIRED` in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428, registered ahead of its producer. - The refusal is gated on the DECLARED `ai.requiresConfirmation === true`, never on `actionLooksDestructive`'s heuristic fallback. Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno Co-authored-by: Claude <noreply@anthropic.com>
api-surface / export-origins gain exactly the three new contract exports; the ledger's reference page and ApiErrorSchema's code enum gain the one new code; spec-changes.json and the upgrade guide pick up the rewritten ADR-0049 entry prose. No removals in any of them. Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7dfa88cca71afdaf94f7243bb4b6bbe2da18e493 && git checkout 7dfa88cca71afdaf94f7243bb4b6bbe2da18e493
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce8caba91403c8f160cb7764c63b08371a13db99 10abca6c2825c3b91e113be6f56ceb3183568adc && git checkout -B drift-repro ce8caba91403c8f160cb7764c63b08371a13db99 && git merge --no-ff 10abca6c2825c3b91e113be6f56ceb3183568adc
node scripts/docs-audit/affected-docs.mjs --json ce8caba91403c8f160cb7764c63b08371a13db99
|
Contract review at
|
…/A7) A1: the ledger row cited UNIQUE_SCOPE_CONFIRMATION_REQUIRED as registered beside the same standard member; it answers 409 and so sits beside RESOURCE_CONFLICT. Citation corrected; 428 unchanged. A3: the producerless row now cites #16293, #15942 and decision batch #54. A4: the retirement prescription and the migration entry asserted present-tense enforcement the runtime does not perform yet, and told an author to invoke a destructive action without the confirmation member "and observe the call refused". Both are now contract-referential and warn that such a call RUNS until the door lands. A5: dropped the non-existent flow `call action` node from the placement rationale and added the stronger reason -- `params` is strict by default (enforceActionParams, ADR-0104 D2), so an undeclared `confirm` there is rejected, not merely a collision. The two destination request shapes are now named. A6: added the missing pin -- ErrorCode admission, the ledger row, the standard-synonym reading and a compile witness for confirmationMember. A7: the contract no longer re-lists `mode: 'delete'` / `variant: 'danger'`; it references actionLooksDestructive, leaving one enumeration site under the #13865 pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
… A4 tense fix The A4 rationale rides in a comment INSIDE the guidance literal rather than above the const: placed above it, it detached the const's own JSDoc and the doc generators re-read that block as the module summary, rewriting content/docs/references/ai/tool.mdx and skills/objectstack-ai/references/_index.md. Both are untouched again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
The changeset body is the release-time carrier of the same claim the guidance and the migration entry were carrying, so leaving it present-tense would publish exactly the sentence the repair pass removed. It now says the contract is a declaration, that no door performs the refusal yet, and it takes the A5 correction on the `params` placement reason. Grade unchanged: minor, additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Delta re-review at
|
…cutionContext` (objectstack-ai#18676) Fixes objectstack-ai#15937 `ToolExecutionContext` in `packages/spec/src/contracts/ai-service.ts` is the contract a tool handler may rely on. A **published handler already authorizes on** `confirmedBlueprintIdentity` — cloud's `apply_blueprint` gate makes a matching blueprint-identity digest one clause of the decision to build a whole app (cloud#1954 / cloud PR objectstack-ai#2005) — while the member it reads was declared only on cloud's own augmented `ToolExecutionContext` and reached by a structural cast. This declares it where the contract lives. Clause-②: yes (widening) — one new OPTIONAL member on a published interface, so the shape a consumer writes against grows. Nothing previously admitted is refused, no member is renamed or retired, and no producer is required to write it. Contract-review tier. The `needs:contract-review` carrier is the seat's on both sides and this PR touches neither. ## The ruling this implements The maintainer's ruling is recorded on objectstack-ai#15937 at comment 5553002827 (2026-09-05), on option 1, verbatim 「同意」. A triage stroke the next morning re-hung `needs-user-decision` on an already-decided card, and the director seat reversed it at 5559258277 with "Nothing to re-decide". So the implementer's brief is the ruling comment, not the card body's two-option analysis. Ruling items 1 and 2 are implemented here. **Item 3 is a read-and-report and is answered below.** **Item 4** — delete cloud's augmentation at `tool-registry.ts:45`, replace the cast in `confirm-gate.ts` with the typed read — is a cloud follow-up card blocked on this field being published and pinned. It is not in this diff, and this seat neither touched cloud nor filed that card. ## What landed **`ToolExecutionContext.confirmedBlueprintIdentity?: string`**, placed next to `userMessageText` because that is the same class of field: route-owner-populated, advisory-to-nobody, never client-derived. Its docblock carries the four things item 1 asks for. - The digest is stamped on a **confirm replay** of an already approved proposal — the consent record saying *this exact blueprint* is the one the user approved. - Populated by whichever layer owns the agent route (cloud, post-cloud ADR-0025), and **only by in-process server code on that route**; never derived from a request body, a tool argument or the transcript. - `undefined` means "no confirmed identity on this turn" and **authorizes nothing** — the same fail-closed reading `actor` and `isSystem` already carry (objectstack-ai#2991). - The consumer is cited by name: cloud#1954 / cloud PR objectstack-ai#2005. **`confirmed-blueprint-identity-contract.pin.test.ts`** — a new pin, following the file's existing `action-confirmation-contract.pin.test.ts` convention. `ToolExecutionContext` had **no test naming it anywhere in this repository** before this PR (`git grep -l ToolExecutionContext` over the tree lit 8 files: two CHANGELOGs, two generated artefacts, one consumer, the contract itself — and zero test files). Six legs, each negative one paired with a lit positive one on the same helper: | leg | what it pins | what makes it able to fail | |---|---|---| | declared on `ToolExecutionContext` | the member, and the digest round-trips | removing the member makes the literal an excess property | | reaches a handler | it travels on `ChatWithToolsOptions.toolExecutionContext` | declaring it on the wrong type compiles nowhere | | optional, absent reads `undefined` | absence is the resting state of a non-replay turn | the lit control reads the same property on a context that carries it | | reads as `string \| undefined` | a handler cannot compile a path that assumes a confirmation | making the member required leaves the directive unused — TS2578 | | typed `string`, not `any` | the member did not arrive untyped | typing it `any`/`unknown` leaves the directive unused — TS2578 | | the spelling is load-bearing | a near-miss is not the confirmation field | renaming the real member reddens the first leg instead | **Changeset**: `@objectstack/spec` **minor**, per ruling item 2. ## Item 3 — `systemInvocation`: read, reported, NOT folded in Measured on this base, repo-wide with same-scope controls: | target | files | reading | |---|---|---| | `systemInvocation` | **0** | the protocol does not declare it either | | lit control `userMessageText` | 1 | the grep reaches, and finds the analogue field | | lit control `isSystem` | 853 | the wide control | | dark control | 0 | — | **Half of the class holds and half is not measurable from here.** The class this card names has two halves: (a) absent from the protocol, and (b) load-bearing for an authorization decision a published handler makes through a structural cast. (a) is confirmed above. (b) lives in `objectstack-ai/cloud`, which is outside this card's scope and which this seat did not open — so it is reported as unmeasured rather than assumed. What the protocol side does say is an asymmetry worth handing to whoever picks this up: **`confirmedBlueprintIdentity` had no protocol counterpart at all**, whereas `systemInvocation` sits beside `isSystem`, which this same interface declares and whose docblock calls it "the ONLY way to obtain system behaviour from the tool loop". So the first question for `systemInvocation` is not "declare it" but "does it duplicate `isSystem`" — and only a "no" makes it this card's case. That is a different first question, so it is **not plainly the same class of omission**, it needs its own card, and this PR does not widen into it. ## Premise re-check on this base The dispatch flagged three premises. All three were re-measured rather than inherited. 1. **The gap is still open.** On base `30be2ac0bb`: `confirmedBlueprintIdentity` across `packages/` = **0 files** (grep exit 1); lit control `userMessageText` = **1 file**; lit control `ToolExecutionContext` = **8 files**; dark control = 0. The one-file control proves the grep reaches `packages/` and finds the analogue field and **nothing more** — the 8-file control is the wide one. 2. **The file moved after the ruling, the interface did not.** `scripts/pm/git-history.mjs touch` answers `fe0d9a4241ab782628e5a7bc4ac61baeb505fbd1`, 2026-09-07T10:00:37Z, +134 lines — two days after the 2026-09-05 ruling, and proved without fetching, the touch and its parent sitting above the shallow floor. Reading that commit's own diff: **not one added or removed line names a `ToolExecutionContext` member**. The +134 is the action-confirmation contract (objectstack-ai#16531) elsewhere in the file. So the interface and `userMessageText`'s docblock were re-read on this base and the docblock matched in shape is the one on the tree; the ruling's quoted field list still describes it, its "UI-context block" being `currentObjectName` / `currentViewName` / `surfaceContext`. 3. **The card's comparison holds.** cloud PR objectstack-ai#2005's body placed `confirmedBlueprintIdentity` "alongside `userMessageText` and `systemInvocation`". Of those three, `userMessageText`'s single repo-wide hit **is** this spec file — the protocol, not an augmentation — while `systemInvocation` has zero. The three were never alike in where they are declared, which is the whole reason this card exists. ## Verification Readings are cited at the commit they were taken on. `pnpm lint` (the repo-wide `eslint . --no-inline-config`) is CI's run; what is below is a declared narrowing with its evidence. - **Build** — `pnpm --filter @objectstack/spec build`, exit 0. `packages/spec/dist/contracts/index.d.ts` carries the member, so the readings below are against a rebuilt tree, not a cached one. `git status` clean after the build: no generator moved a tracked artefact, and `api-surface/contracts.json` is unmoved because it records exported symbol names, not interface members. - **Type-check** — `pnpm --filter @objectstack/spec typecheck`, exit 0 (that is `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` in series). The test-layer ratchet holds at 54 files / 259 errors / 144 pinned signatures, unchanged. - **Tests** — `pnpm --filter @objectstack/spec test`, exit 0: **486 files, 13856 tests passed**. The new pin, run on its own, is 6 passed. - **Ablation, direction predicted before the run: RED.** From the committed state, the declared member was renamed on disk (delete-anchor 1 to 0, inject-anchor 0 to 1, blob hash moved `bf79a0cc` to `a646979419`), and `check:test-typecheck` went from exit 0 to **exit 1, naming 10 type errors in the new pin file**. The restore leg put HEAD's bytes back — `git hash-object` back to `bf79a0cc3f6fb0505e0edf6c5665c3f004e371f4`, `git diff HEAD` empty, `git status --porcelain` empty — and the same gate returned to exit 0. Both legs ran under a `trap ... EXIT INT TERM` restore with absolute paths. No `dist` preflight: the pin imports `./ai-service` relatively, so it resolves to `src`, and a `dist` reading would be about the wrong artefact. - **Lint, narrowed and declared**, at `5ddd6f45ee`. ① The population comes from eslint's own config: the base block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, so the changeset `.md` is outside it entirely — eslint says so itself, "File ignored because no matching configuration was supplied". ② `--format json` counts 3 entries, of which 2 are in-population: **0 errors, 0 warnings** on both `.ts` files. ③ Invariance, quoted from `eslint.config.mjs`: "this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not." With no type-aware rule, nothing in this diff can move the verdict on a file the diff does not touch; the config's file rosters are likewise untouched. - **Control bytes** — `check:nul-bytes` exit 0 over 8798 files, plus a direct `grep -naP` sweep of the three changed files, which found none. The full re-derived gate sweep, every exit code, and the reconciliation against `dispatch-gates.mjs --ran` are in the `os-dev-report` comment on objectstack-ai#15937. ## Acceptance notes Noted while in the file, **not filed and not fixed here**: - `userMessageText`'s docblock, the line directly above the new member, reads "(cloud, post-ADR-0025)" — a **bare** ADR number for what is, in context, cloud's record. AGENTS.md Prime Directive 13 is explicit: an ADR lives in the repository whose code it governs, cloud decisions are cited "as `cloud ADR-NNNN` — ⛔ never as a bare number, which `scripts/check-adr-anchors.mjs` resolves against *this* registry (the two number independently)". This repository's own `ADR-0025` is `docs/adr/0025-plugin-package-distribution.md`, and every other bare `ADR-0025` in the tree means that one, so a reader following this citation lands on a real page about plugin packaging. The new member spells it `cloud ADR-0025`, which is why the two lines differ. **Not folded in**: it is a different defect class from this card's, so the bounded in-place exemption does not open, and `check:adr-anchors` does not read source docblocks at all (it checks only the entries under `scripts/adr-anchors/`), so nothing is currently red. Reported with dedupe words in the report comment for the triage seat to file or discard. - `ToolExecutionContext` carrying **no test at all** before this PR is now half-closed: the new pin asserts the new member and the thread it travels on, deliberately not the other twelve members. Widening it to the whole interface would be scope this card does not carry. Provenance: authored by an `os-dev` seat under the PM dispatch on objectstack-ai#15937, session `session_01LvwGppdonww4zGLWZo5rho` (https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho). --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16293
Clause-②: yes
Executes decision batch #54 (maintainer 「同意」 on recommendation A). This is the spec half only — it defines the contract and registers the refusal code. The runtime enforcement in
invokeBusinessActionis #15942, which remains open and is not addressed here.Contract review at
CONTRACT_REVIEW_TIER(claude-fable-5-1): PASS — no blocking findings (comment 5567317321). Six advisories were held for a repair pass; all six have landed and are itemised under The repair pass below. The load-bearing decision was re-confirmed by that review and is untouched: the refusal gates on the DECLAREDaction.ai.requiresConfirmation === true, never onactionLooksDestructive, and the runtime is not in this diff.The four deliverables
1. The request-side contract —
packages/spec/src/contracts/ai-service.ts, directly underAIToolDefinition:AI_ACTION_CONFIRMATION_MEMBER = 'confirm'— one exported constant fixing the member's spelling, so the door that refuses and the client that retries read the same symbol instead of each hand-spelling it.AIActionConfirmation— the closed boolean member, declared once and mixed into each door's request shape rather than restated by it.It rides at the top level of the action request, and the contract now NAMES the two request shapes it is destined for rather than gesturing at them: the MCP
run_actiontool input (actionName/objectName/recordId/params,packages/mcp/src/mcp-http-tools.ts) and the runtime action door's own request object — theinputargument ofinvokeBusinessAction(objectName/recordId/params,packages/runtime/src/action-execution.ts), which the MCP bridge builds that object from.Two placements were considered and rejected, and the reasons are written into the contract:
params. That bag is CLOSED against the author's own declared input vocabulary:enforceActionParams(ADR-0104 D2, strict by default since 17.0) rejects any key that is neither a declared param nor a built-in, so on an action that declares params a platformconfirmriding there is REFUSED as an unknown action param — a 400 raised before the confirmation gate is ever reached. On an action declaring no params that check is a pass-through, so the same member would be silently accepted instead: one placement, two opposite behaviours, which on its own disqualifies it for a safety member.2. The refusal —
ACTION_CONFIRMATION_REQUIRED, registered inERROR_CODE_LEDGERunder@objectstack/runtime(the package that owns both action doors, beside itsACTION_DISABLEDsibling), answered 428.error.detailsisActionConfirmationRequiredDetails: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing the prose it was handed.3. The two misleading passages —
ai/tool.zod.ts'srequiresConfirmationretirement guidance and the ADR-0049 semantic entry'sreplacement/acceptanceCriteria— rewritten so they no longer describe an approval queue, and (after the review) so they no longer assert enforcement that does not exist yet. See A4 below: the wording is contract-referential and says plainly that the flag stops nothing until the door lands.4. Changeset
minoron@objectstack/spec. Additive: three new exports, one new union member, zero removals —check:api-surfacereports the surface unchanged apart from the additions.Which predicate gates the refusal, and why
The DECLARED flag:
action.ai.requiresConfirmation === true. NeveractionLooksDestructive.The MCP
list_actionsprojection atpackages/runtime/src/action-execution.ts:1014readsand
actionLooksDestructive(:979) returns the declared flag when the author set it and otherwise falls through to a destructiveness heuristic, per the #7828 Option A ruling. So the listing reports declared-flag-or-guess.The ruling says "an action declaring
ai.requiresConfirmation: true". Writing the refusal againstactionLooksDestructivewould make an action whose author declared nothing start refusing on a guess — well past what was ruled, and in the direction that breaks working callers.The two are not the same predicate and the contract says so explicitly. They also answer different questions, so both are honoured without contradiction:
truetruefalsefalsetrue(heuristic)The member is accepted on every AI-facing action call and required only on the declared-gated ones. So a client that confirms whenever a listing says
requiresConfirmation: trueis always correct — over-confirming is free — while inferring "no door will ever refuse" from a listing'sfalseis sound precisely because the listing's predicate is the wider of the two.list_actionsis unchanged.Why 428, and why a ledger code beside
PRECONDITION_REQUIREDThe card rules 4xx: the request is valid but incomplete, and the caller can fix it. 428 is the platform's own reading of exactly that condition —
packages/cli/src/utils/secret-reference-union.ts:189already records it as "the standard catalog's 'request is missing a required precondition'". 409 was rejected: there is no state conflict, and the identical call with the member set succeeds against unchanged server state. 422 was rejected: the definition is fine.The code is not a re-spelling of the standard member.
PRECONDITION_REQUIREDsays some precondition is missing, which leaves a caller unable to tell a confirmation gate from a missing conditional header and unable to build the retry; this code says which precondition, on which action — the same discrimination its*_DISABLEDandFLOW_*neighbours make. Measured:standardSynonymOf('ACTION_CONFIRMATION_REQUIRED')returnsundefined, withCONFLICTandFORBIDDENlit as positive controls in the same run — now a standing pin rather than a one-off probe.Registered ahead of its producer by design — the
FLOW_INPUT_SCHEMA_INVALIDsplit shape the ledger already sanctions — and the row cites the cards that make it a split rather than a residue (#16293 for this half, #15942 for the producer, decision batch #54 for the ruling that split them).Deliberately not done here
confirmmember added to the live MCPrun_actioninputSchema(packages/mcp/src/mcp-http-tools.ts). Adding it before MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 enforces it would ship a member every LLM client can see and the runtime accepts and ignores — a safety flag that is merely accepted, which is precisely the defecttool.requiresConfirmationwas retired for under ADR-0049. The spec declares the member; each door grows it in the same change that enforces it, so no window exists where it is accepted and ignored. The hand-off is recorded on MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 (comment 5567321513).ui/action.zod.ts'saiguidance, are untouched. The card assigns the first to MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942; the second sits underaction-requires-confirmation-docblock.pin.test.ts.content/docs/ai/actions-as-tools.mdxis accurate today and becomes incomplete only once the server refuses — MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942's job, already on the drift bot's list.The repair pass — the six advisories held from the review
All measurements below were taken in a worktree pinned to merge-base
a5eccf9257, at final head10abca6c28.UNIQUE_SCOPE_CONFIRMATION_REQUIREDas "registered beside the same standard member for the same reason". Measured: that gate answers 409 (packages/cloud-connection/src/marketplace-install-local-plugin.ts:829closes}, 409); its own ledger row says 409;HttpStatusErrorCodeMapmaps409toRESOURCE_CONFLICTand428toPRECONDITION_REQUIRED). It is therefore a sibling in KIND only — the platform's other confirmation gate that registers its own code — and no precedent for choosing 428 over 409. 428 itself is unchanged.[#16293]heads the row in the sibling convention, and the producerless paragraph names MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 and decision batch Prepare v0.2.0 release for ObjectStack packages #54, with one sentence saying why the numbers are the point: a producerless row with no card behind it is the "registered but unemittable" retirement class, and this one is a split.acceptanceCriteriatold an author to "invoke it WITHOUT the confirmation member and observe the call refused" — an instruction that, before MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942, executes the destructive action. All three sites now describe what the contract DECLARES, say plainly that the door which performs the refusal has not landed, and explicitly warn against that verification: "until that door ships, such a call is not refused, it RUNS". The queue language is NOT restored — it stays deleted, exactly as the ruling requires. Nothing here relies on release ordering.minor, additive).check:doc-authoringreds on an internal issue id inside a customer-facing prescription (a hoistedguidanceconst is one of its recognised positions; ADR ids stay,#NNNNdoes not). The migration entry'sreplacement/acceptanceCriteriaare not swept — measured against dozens of sibling semantic entries that carry#NNNNin those same two fields today, on a green tree — so both name MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 directly.call actionnodes" is gone:FlowNodeAction(packages/spec/src/automation/flow.zod.ts:27) enumeratesscript/subflow/connector_actionand no action-call node (control:subflowis present in the same enum). Added, as the decisive reason the contract was missing:paramsis strict by default, so an undeclaredconfirmthere is REJECTED rather than merely colliding — with the asymmetry spelled out, sinceenforceActionParamsis a pass-through for an action that declares no params.git grep -lover*.test.ts/*.spec.tsreturned 0 files for each ofAI_ACTION_CONFIRMATION_MEMBER,AIActionConfirmation,ActionConfirmationRequiredDetailsandACTION_CONFIRMATION_REQUIRED, while the same glob lights 26 files forERROR_CODE_LEDGERand the same terms light non-test files.packages/spec/src/contracts/action-confirmation-contract.pin.test.tsnow carries the feat(spec): register FLOW_INPUT_SCHEMA_INVALID — the never-dispatched ADR-0112 code for the definition-level input-schema refusal #12611 shape — an accepts case throughErrorCode.parse, the ledger row under@objectstack/runtime(plus a control that no other owner holds it),standardSynonymOf(...)undefined against two lit controls — and the compile witness:confirmationMembertyped totypeof AI_ACTION_CONFIRMATION_MEMBER, with a near-miss spelling and a stringconfirmeach behind a@ts-expect-error. Those two directives are LIT rather than decorative: an unused directive is TS2578, this file carries no entry intest-typecheck-debt.json, andcheck:test-typecheckis green — which is only possible if both are consumed.actionLooksDestructiveby name and says the signals are named once, beside the authorable key inui/action.zod.ts. Why removal over extending the pin: the enumeration is a RUNTIME implementation detail, andActionAiSchema.requiresConfirmationdocblock still namesconfirmTextas a destructive signal — the #7828 ruling removed that leg #13865's pin exists to hold ONE authoring surface against the classifier. Extending it to a second site would freeze the same list in two places and make both rot-prone; deleting the restatement leaves nothing to rot and keeps the pin's subject singular. The changeset keeps its one mention, deliberately: it is frozen intoCHANGELOG.mdat release as a record of the decision, not a live authoring surface.Accepted as-is per the review's rulings: A8 (the
acceptanceCriteriarewrite is in-spirit and kept, with the A4 tense fix), A9 (minoris correct — not re-graded), A10 (actions-as-tools.mdxis #15942's).Verification — all readings on
10abca6c28, merge-basea5eccf9257node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...reconciles clean at this head. 100 green; 2 NOT MEASURED, both exit 3 (PREREQUISITE NOT MET, which the scripts themselves say is neither a pass nor a finding) and both needing a whole-tree build this container cannot fit in one foreground window:check:dual-build-cjs-loads(no new module and no new import edge hides behind it) andcheck:type-check-debt(its one moved package,@objectstack/spec, is separately green below, source + scripts + test layers). Both are declared to CI. Four further gates first returned exit 3 or a build-first exit 1 and were re-run to a real green after building the@objectstack/lint,@objectstack/clientand@objectstack/client-reactclosures.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date, aftergen:migration-registryand then exactly the four--fixproved stale (gen:spec-changes,gen:upgrade-guide,gen:skill-refs,gen:docs) — never the whole set. Every regenerated diff was read: the registry,spec-changes.jsonand the upgrade guide carry the corrected prose and nothing else.//block aboveTOOL_RETIRED_KEY_GUIDANCE, which detached that const's own JSDoc and made the doc generators re-read it as the module summary — rewritingcontent/docs/references/ai/tool.mdxandskills/objectstack-ai/references/_index.md. The comment now sits INSIDE the object literal beside the key it explains, and both files are untouched in this diff.pnpm --filter @objectstack/spec test— 484 files / 13142 tests passed (from 483 / 13136: the new pin).pnpm --filter @objectstack/spec typecheckgreen acrosstsc --noEmit,check:scripts-typecheckandcheck:test-typecheck(54 files / 261 errors / 145 pinned signatures held, unchanged — the new file adds none).eslint . --no-inline-configover 6274 files — 0 errors, 0 warnings, run at this head (6273 before; the delta is the new test file).check:nul-bytesgreen; plus an independent control-byte sweep of the 13 changed paths (grep -naPover the control ranges), no hits.check:changeset-gate-self-tests,check:objectui-changeset,check:doc-authoring,check:keyed-text-bounds,check:nul-bytes,check:error-code-casing,check:comment-mask-adoption,check:comment-mask-corpus,check:closing-keyword-parity,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:dispatcher-error-vocabulary,check:error-code-provenance.dispatch-gatesreports this tree as at least 24 commits behindorigin/main, with five family-defining files (two workflows,scripts/nightly-tiers.*,check-skill-line-ratchet.mjs) changed across that range. The derivation is therefore against THIS branch's copies of those files. The change set itself is anchored to the literal merge-basea5eccf9257, never to a moving ref.Review posture
needs:contract-reviewstays on this PR and on #16293. The PR stays draft — not flipped ready, not enqueued, no auto-merge — per the reviewing seat's instruction.Authored by Claude Code in session
session_01T6HeZvT9wdSJD1ZxJb5Eno(durable attribution, kept in prose).